
做电机驱动、RC遥控解码、超声波测距这类项目时脉冲宽度测量几乎是躲不掉的需求。我最早做这块用的是最常规的单通道输入捕获加中断每次捕获进中断读一次CCR。PWM频率低的时候问题不大一旦到10kHz以上每秒钟要进两万次中断CPU大量时间花在压栈出栈上频率再往上走捕获事件开始丢失数据就完全没法用了。后来我把方案改成双输入捕获加DMA也就是定时器的CH1抓上升沿、CH2抓下降沿两个通道各自通过DMA把捕获值搬到内存数组CPU只在DMA半满或者全满的时候做一次批量计算整个系统才算真正跑稳。这篇笔记就把这个方案的底层原理、CubeMX配置、软件处理思路和实际踩坑记录完整过一遍适合正在做STM32信号测量、PWM解码或者想把捕获与DMA配合细节彻底搞清楚的开发者参考。1. 为什么脉冲宽度计要选双通道捕获DMA1.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负载。中断方式是每次事件打扰一次CPUDMA方式是攒够了一批数据再打扰一次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.9nsAPB2是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或者TIM584MHz下回绕周期超过51秒绝大多数脉宽测量场景都不会碰到回绕二是用更新中断做溢出计数扩展成64位时间戳三是给预分频器加分频但这样会牺牲分辨率不推荐。2.4 澄清一个关于高精度的误解这里想多说一句。很多人以为DMA方案比中断方案更精确其实并不完全对。中断方式里CCR也是硬件锁存的中断延迟并不会污染时间戳本身。DMA真正的价值是搬运过程不打断CPU从而支撑更高频率的连续捕获并且避免中断响应延迟对后续软件计算造成的抖动干扰。换句话说如果你只是偶尔测一个脉冲中断方式完全够用如果你要在高频PWM下持续测量并且CPU还要同时跑显示、通信、控制算法那DMA就成了刚需。清楚这一点遇到测量数据异常的时候才不会把问题归因到错误的位置。3. CubeMX配置两个捕获通道加两路DMA3.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。关键参数如下DirectionPeripheralToMemory外设到内存ModeCircular循环模式Peripheral Increment关闭CCR寄存器地址固定Memory Increment开启数组地址递增Data Width16位定时器选HalfWord32位定时器选WordPriority可以选High测量实时性优先数据宽度这个参数特别容易出错。定时器CCR寄存器是16位就用HalfWord是32位就用Word。如果32位定时器配了HalfWord计数超过65535之后时间戳的高位丢了算出来的脉宽会莫名奇妙地偏小或者跳变。数组元素的类型也要跟着匹配16位选uint16_t32位选uint32_t。还有一个坑在DMA中断这里。很多人在CubeMX里只勾了Half Transfer和Transfer Complete但忘了到NVIC设置里使能对应的DMA中断通道程序跑起来回调永远不触发。务必在NVIC页面确认对应DMA Stream的中断优先级是Enabled状态。3.3 时钟树里最容易被忽略的倍频关系配置时钟树时要特别留意定时器输入时钟。CubeMX里选中定时器后右侧可以看到Timer Input Frequency。F407跑168MHz主频时APB1定时器显示84MHzAPB2定时器显示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.28msCPU在这个时间内处理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[i1] - 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.9ns168MHz对应约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上跑的一组数据定时器用TIM484MHz计数时钟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省下来的资源让项目整体稳定性上了一个台阶。