ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32双输入捕获+DMA实现高精度脉冲宽度测量

STM32双输入捕获+DMA实现高精度脉冲宽度测量 最近调了一版基于STM32的高精度脉冲宽度计把方案定在“双输入捕获DMA”这条路上整体跑下来效果很稳CPU占用也低得可以忽略。做这类测量的朋友应该都知道脉冲宽度计看着简单不就是数一下高电平持续多长时间嘛但一旦要求“高精度”“连续测量”“不能丢脉冲”传统的定时器输入捕获直接套中断的做法很快就会翻车。这篇笔记把我从原理到代码到调试验证的过程完整梳理一遍重点讲清楚为什么选择双通道捕获DMA以及实际配置和计算时那些最容易踩的坑。这篇内容适合正在做信号测量、电机测速、PWM解调、传感器脉宽输出解析的朋友哪怕你对定时器捕获只有个模糊印象照着步骤走也能把高精度脉宽计搭起来更重要的是搞懂每个环节背后的理由后面换成别的芯片、别的场景也能自己改。1. 为什么脉冲宽度计要“双输入捕获DMA”1.1 传统输入捕获方案的痛点STM32的定时器输入捕获基本逻辑就是检测引脚上的边沿信号在边沿到来的瞬间把当前计数器的值锁存到捕获寄存器里这样软件就能读到边沿发生的“时间戳”。单通道测脉宽最常见的做法是配置同一通道捕获上升沿进中断后马上改成捕获下降沿下降沿再进中断时读两次时间戳相减然后改回上升沿如此反复。这套流程在低频率下没有问题比如测量一个几百Hz的方波中断频率不高CPU完全能应付。可一旦信号频率上去比如测几十kHz甚至上百kHz的脉冲串每个边沿都触发一次中断中断服务程序里还要做改边沿极性、清标志、读寄存器、算差值这一串操作中断响应延迟和指令执行时间就会严重影响测量精度。更麻烦的是中断嵌套和优先级处理稍有不慎就会漏掉边沿一个漏了就导致后面所有宽度全部错位。有人会说那我用定时器的PWM输入模式一次就能同时测频率和占空比何必这么折腾。确实PWM输入模式适合测周期性方波但对任意脉冲串比如编码器输出的非等宽脉冲、传感器发出的单次脉宽信号它就不适用了。PWM输入模式本质上是靠连续两个边沿的时间差算周期遇到两个脉冲间隔不固定的场景读出来的数值就完全没有意义。1.2 双通道捕获与DMA的组合思路既然单通道中断方式在高频下撑不住思路就转向了“硬件尽量多干活软件尽量少插手”。STM32定时器一个关键特性就是多个捕获通道可以独立配置极性互不干扰。以基本定时器为例通道1可以配成上升沿捕获通道2可以配成下降沿捕获两路同时监听同一个输入信号。上升沿到来时硬件把TIMx_CCR1锁存成当时的计数器值下降沿到来时TIMx_CCR2锁存成当时的计数器值。两个时间戳在同一个边沿对里被硬件分别记录下来软件只需要把两者相减就得到了完整的高电平持续时间。这还没完真正让CPU解放出来的是DMA的加入。定时器捕获事件发生时会产生DMA请求如果我们把捕获寄存器配置成DMA的外设源地址那么每个边沿到来时DMA控制器会把捕获寄存器里的时间戳自动搬运到内存数组里整个过程不需要CPU参与。CPU要做的只是定期检查内存缓冲区把成对的时间戳取出来做运算。这样一来哪怕信号频率很高中断频率也被压到了几乎可以忽略的程度CPU占用大幅下降测量精度也不再受中断延迟影响。这套方案还有一个隐性优势捕获时间戳是硬件锁存的DMA搬运是在捕获事件之后由硬件自动完成的所以时间戳的“时间分辨率”完全取决于定时器计数频率和边沿与时钟沿之间的相位差和软件执行速度无关。这就是所谓的高精度来源精度级别从“微秒级看CPU心情”提升到“几十纳秒级由硬件时钟决定”。2. 高精度脉冲宽度计的总体设计思路2.1 双输入捕获测量脉宽的工作原理具体到工作原理我画个简化时序方便理解。假设定时器已经启动计数器从0开始递增每来一个时钟脉冲加1加到ARR设定的上限后归零重新开始这个归零就是溢出。输入引脚上送来一个待测脉冲高电平开始的那一刻通道1检测到上升沿硬件将当前计数器的计数值锁存到CCR1高电平结束的那一刻通道2检测到下降沿硬件将当前计数值锁存到CCR2。如果这个脉冲宽度没有跨越定时器溢出点那么脉宽对应的计数值就是CCR2减CCR1。再拿这个计数值乘以计数器时钟周期就得到真实时间。比如定时器时钟84MHz每个计数代表约11.9nsCCR2和CCR1相差1000那么脉宽就是11.9微秒左右。如果脉冲跨过了溢出点CCR2的数值会小于CCR1这时直接相减会得到负数需要把计数器溢出次数考虑进去。所以固件里除了DMA搬时间戳还得开一个溢出中断用来累计计数器回绕了多少次。计算时间戳真实值时要把溢出次数乘以(ARR1)再加上捕获寄存器的值。这个细节特别重要后面代码部分我再展开。为什么不用中断去读CCR1和CCR2因为中断从触发到读取寄存器之间有延迟这个延迟通常是几微秒到几十微秒取决于中断优先级、现场保护、Flash等待周期等因素而且这个延迟还不固定。双通道DMA的方式避开了所有中断延迟时间戳在边沿到来瞬间由硬件锁存DMA随后的搬运动作并不改变时间戳的内容。说白了精度从“软件快照”变成了“硬件快照”这是质的区别。2.2 定时器选型与DMA映射关系做STM32项目第一步就是选对芯片、选对定时器。我这次用的是STM32F407系列片内定时器资源丰富主要是看中了TIM2和TIM5这两个32位高级定时器。32位计数器意味着计数器最大计数值能到42亿多即使时钟很高溢出周期也足够长对于大多数脉宽测量需求基本不需要频繁处理溢出简化了软件逻辑。如果用的是STM32F1系列定时器大多是16位的计数器最大只能数到65535。拿84MHz的计数频率来算溢出周期不到0.8毫秒脉宽稍微宽一点就会溢出软件里对溢出次数的处理要求就更高了。F1系列在DMA映射上也要特别留意它的定时器DMA请求是固定的没有DMAMUX这个模块来做灵活映射配置时只能按芯片手册的固定表来。DMA请求映射是个非常容易出问题的点。以F407为例TIM2的捕获比较事件可以产生DMA请求但这个请求具体连接到DMA1还是DMA2的哪个流和哪个通道必须查参考手册的DMA请求映射表不能凭感觉选。如果DMA请求映射配置错了表面上看外设和DMA都初始化成功了可信号来了缓冲区就是不动查半天都查不出原因。G4和L4系列新增了DMAMUX映射配置更灵活但同时也多了一步“选择请求源”的操作原理都一样都是把定时器的DMA请求正确引到对应的DMA通道上。2.3 精度指标与测量范围估算技术方案定下来后必须先把指标算清楚不然做到一半才发现分辨率不够或者量程不够返工成本就高了。脉冲宽度计的核心指标有两个分辨率能区分的最小时间变化和最大可测脉宽。分辨率由计数器时钟周期决定即1除以定时器时钟频率。F407的定时器时钟可以到84MHz分辨率约11.9ns。如果把定时器时钟推到更高频率比如用内部PLL把APB1定时器时钟提到84MHz以上分辨率还能再提升。但这里有个现实约束输入信号本身的边沿抖动、芯片引脚输入触发器的建立保持时间都会对最终测量结果造成影响过分追求分辨率数字意义不大100MHz左右的计数时钟已经能覆盖绝大多数工业测量需求。最大可测脉宽分两种情况。对32位定时器计数满量程按84MHz算能覆盖约51秒的宽度绝大多数场景都够用。对16位定时器直接用满量程算只有不到0.8毫秒这时必须配合溢出计数修正实测就能覆盖到任意宽度代价是溢出中断的频率会变高。比如测量一个10毫秒的脉冲计数器每0.78毫秒溢出一次整个脉冲期间要触发十几次溢出中断虽然逻辑不复杂但中断数量上来了对实时性要求高的场景要考虑是否可接受。我这次设计目标很明确频率范围100Hz到100kHz的脉冲串脉宽范围1微秒到5毫秒分辨率优于100ns。按F40732位定时器来算这个指标是完全能覆盖的。大家在设计时也可以按这个思路先列指标再反推定时器位数、时钟频率和预分频系数不要上来就凭着感觉抄代码。3. 定时器与DMA的配置实操3.1 参数计算分辨率、预分频与ARR配置定时器时几个关键参数必须算清楚否则运行起来完全不是预想的效果。首先是预分频系数PSC。定时器时钟经过PSC分频后才是计数器时钟计数器时钟频率等于定时器时钟除以(PSC1)。我需要分辨率优于100ns即计数器周期小于100ns也就是计数频率要大于10MHz。F407的定时器时钟84MHz如果PSC设为0计数频率就是84MHz分辨率约11.9ns完全满足要求。PSC设为1时计数频率变成42MHz分辨率约23.8ns也够用。这里我选择PSC0尽量发挥高分辨率优势。其次是自动重载值ARR。计数器从0数到ARR后溢出归零溢出周期为(ARR1)除以计数频率。为了尽量延长溢出周期可以把ARR设到定时器的满量程值。32位定时器ARR设为0xFFFFFFFF也就是说计数器在0到42亿多之间循环完全不担心溢出。F1的16位定时器ARR最大只有0xFFFF溢出周期短就要靠溢出中断来扩展这个差异直接影响后续代码逻辑。还有一个容易被忽略的参数是定时器计数模式。默认向上计数模式适合本场景边沿捕获的时间戳就是计数器的瞬时值。有些朋友习惯把定时器配成中心对齐模式做PWM输出如果照搬到捕获场景计数器先增后减同一时刻的计数值对应的时间关系就变得复杂处理起来容易出错。输入捕获测量就老老实实用向上计数模式简单直接。3.2 CubeMX配置要点我用STM32CubeMX做初始配置图形化点选比手写寄存器快很多关键是几个容易漏的选项别漏掉。定时器配置页面选择TIM2时钟源选Internal Clock。通道1配置为Input Capture direct mode极性选Rising也就是上升沿捕获通道2同样配置为Input Capture direct mode极性选Falling下降沿捕获。预分频PSC设为0计数模式Up自动重载ARR设为0xFFFFFFFF。这里有个小细节通道1和通道2要配置成独立模式不要用Combined PWM输入模式我们就是要两路独立的边沿锁存。定时器的NVIC配置页面打开TIM2全局中断这个中断用于计数器溢出处理。还要特别注意在定时器配置中使能DMA请求。在CubeMX的DMA Settings标签页里添加两个DMA请求一个对应TIM2_CH1的捕获一个对应TIM2_CH2的捕获。DMA方向都选PeripheralToMemory外设地址由CubeMX自动填成对应捕获寄存器内存地址我们用一个自定义数组。数据宽度、循环模式、优先级这些参数稍后细说。DMA请求映射这一步CubeMX如果版本和芯片支持DMAMUX会要求你指定请求源选择Timer的Capture Compare事件即可。对于F407这种没有DMAMUX的芯片CubeMX会直接把定时器事件映射到它固定的DMA通道上自动完成你要做的只是确认所选DMA句柄和参考手册里的映射表一致。我这次是TIM2_CH1的DMA请求映射到DMA1的数据流上TIM2_CH2的映射到另一条具体要看CubeMX生成代码的DMA句柄编号。3.3 DMA缓冲与中断方案设计DMA参数里最关键的一项是数据宽度。定时器的捕获寄存器CCR1和CCR2在寄存器层面上都是32位的哪怕芯片是16位定时器访问这个寄存器时也按32位处理。所以DMA的数据宽度必须配成Word32位源地址和目标地址的宽度都要一致。如果配成Half WordDMA搬运时只搬低16位虽然看起来数据也能读到但寄存器地址指针按半字步进搬运位置完全错乱算出来的脉宽值毫无意义。内存缓冲区我分配了两个数组分别存放两个通道的时间戳。数组元素类型是uint32_t长度根据实际需求定我用了256配合循环模式。DMA传输模式选Circular循环模式意思是每次捕获事件触发一次搬运搬完一次后DMA通道不会自动关闭而是继续等待下一个捕获事件缓冲区填满后自动绕回开头。循环模式对连续测量是必须的否则第一个边沿把数据搬完DMA传输完成标志置位后通道就停了后面的边沿再也不会被搬进内存。DMA中断方面我启用了两个关键中断传输过半中断和传输完成中断。缓冲区长度256传输过半意味着前128个数据已经搬完传输完成意味着256个数据填满。在两个中断里分别处理已经搬完的那段数据及时算掉脉宽、清理计数器状态这样缓冲区就能持续循环使用不会因为来不及处理而覆盖未读数据。还需要开启DMA传输错误中断DMA搬运出错时能第一时间发现问题不至于数据错乱后还在那傻算。4. 固件逻辑与核心代码实现4.1 启动采集的代码流程CubeMX生成工程后主要的固件逻辑集中在几个回调函数里。开始采集时用HAL库的接口启动两个通道的捕获DMA传输这一步同时完成了定时器启动、捕获使能、DMA搬运三步动作uint32_t rising_timestamps[256]; uint32_t falling_timestamps[256]; void pulse_width_start(TIM_HandleTypeDef *htim) { HAL_TIM_IC_Start_DMA(htim, TIM_CHANNEL_1, (uint32_t *)rising_timestamps, 256); HAL_TIM_IC_Start_DMA(htim, TIM_CHANNEL_2, (uint32_t *)falling_timestamps, 256); __HAL_TIM_ENABLE_IT(htim, TIM_IT_UPDATE); }注意两个通道的启动顺序没有严格要求因为两个通道都在监听同一个输入信号只要在信号到来之前启动完成就行丢失第一个半脉冲的情况在配对逻辑里统一处理。DMA搬运是硬件自动完成的我们不需要在每个边沿中断里做任何事。定时器的溢出中断还是需要的溢出次数累加volatile uint32_t overflow_count 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { overflow_count; } }这里有个细节溢出中断里只做计数累加不做任何复杂运算中断服务程序尽量短避免影响系统实时性。如果溢出频率很高可以考虑把溢出计数变量声明为volatile并且用临界区保护防止主循环读取到半更新状态。我的场景里32位定时器基本不溢出这个中断属于兜底性质负担很轻。4.2 回绕修正与脉宽换算时间戳都进缓冲区以后关键的一步是换算。上升沿在第i个缓冲区位置存了rising_timestamps[i]下降沿在同一脉冲对里存了falling_timestamps[i]。脉宽对应的计数值按下面方式计算uint32_t ticks; if (falling_timestamps[i] rising_timestamps[i]) { ticks falling_timestamps[i] - rising_timestamps[i]; } else { // 下降沿发生在计数器回绕之后 ticks (0xFFFFFFFF - rising_timestamps[i]) falling_timestamps[i] 1; }第一种情况下降沿和上升沿在同一个计数器周期内直接用差值。第二种情况上升沿在溢出前下降沿在溢出后图片上CCR2的数值反而小于CCR1这时要把回绕前后的两部分加起来。由于我用的32位定时器ARR是0xFFFFFFFF所以绕一圈就是0xFFFFFFFF加1即2的32次方。对于16位定时器公式里的0xFFFFFFFF要换成0xFFFF但原理完全一样。有人认为溢出计数也能修正回绕实际上如果脉宽不超过一个溢出周期用上面的差值法就足够了只有当脉宽跨越多个溢出周期时才需要把overflow_count引入计算。因为我的应用场景脉宽最大5毫秒32位定时器溢出周期51秒永远不会出现跨多周期的情况所以软件里就按单周期回绕处理了。脉宽的真实时间就是ticks乘以计数时钟周期float pulse_width_us (float)ticks / 84.0f;84代表计数频率84MHz所以tick数除以84就是微秒数。如果计数器时钟42MHz这里就除以42。注意结合PSC修正我的PSC0所以直接用84PSC不为0时要用定时器时钟除以(PSC1)的结果。4.3 连续采集与配对逻辑缓冲区处理放在DMA的回调里。我用半满中断处理前256个数据全满中断处理后256个数据每次处理的数据块都是定长的这就让配对逻辑变得很流畅。半满和全满中断对应的HAL回调是void HAL_TIM_IC_CaptureHalfCpltCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { process_pulse_block(0, 127); } } void HAL_TIM_IC_CaptureCpltCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { process_pulse_block(128, 255); } }注意一个问题两个通道的DMA都配了半满/全满中断但实际处理时只需要在一个DMA流的中断里做数据解析。为什么因为两个通道的搬运是同步的上升沿和下降沿交替到来缓冲区写入的节奏一致两个通道的缓冲区同一位置存的是一个脉冲对的时间戳。所以只在通道1的中断里做解析就够了通道2的DMA只负责搬数据不触发处理逻辑这是配置时的一个小技巧能避免重复处理数据。配对逻辑还需要处理信号起始电平的问题。如果信号在开始采集的那一刻是高电平那么第一个到来的边沿是下降沿这个下降沿之前并没有对应的上升沿如果直接配对第一次算出来的脉宽就是错的。我的处理方式是在处理函数里记录首个边沿类型如果首事件是下降沿就把它丢弃从第一个上升沿和一个下降沿开始配对static uint8_t skip_first_falling 0; void process_pulse_block(uint16_t start, uint16_t end) { // rising_timestamps与falling_timestamps一一配对 }简单场景下也可以在采集开始前确保信号是低电平状态从上升沿开始测。但工程上信号状态不可控用代码处理更稳妥。更好的做法是同时记录每个缓冲区位置对应的边沿类型这个做法就是为每个通道分别维护不同的缓冲区它们天然区分了边沿类型配对顺序上注意起始状态即可。5. 常见问题与排查技巧实录5.1 常见问题速查表这套方案调试过程中有几个问题是出现频率最高的我整理成一张表方便大家直接对照排查。现象可能原因解决办法缓冲区数据全为0输入信号没接对或定时器通道引脚复用配置错误用示波器查引脚波形核对GPIO复用功能缓冲区有数据但数值都是同一个值DMA外设地址配错搬送的寄存器不含捕获锁存值检查DMA外设地址是否为CCR1/CCR2寄存器地址数值有明显错位偶数和奇数位置内容颠倒DMA数据宽度配置为Half Word数据宽度统一改为Word只有第一次有数据后续不再更新DMA没有配置循环模式传输模式改为Circular两个通道数据长度不一致信号起始电平造成首尾各缺一个边沿按起始电平做配对偏移处理脉宽偶尔偏大或偏小一两个tick计数器回绕导致上升沿和下降沿跨周期用差值法处理CCR2小于CCR1的情况系统卡死或频繁进HardFaultDMA中断和溢出中断冲突缓冲区越界检查中断优先级分组确认缓冲区索引不越界5.2 几个容易踩的坑第一个坑是DMA映射表。使用F407这类不带DMAMUX的芯片时定时器事件的DMA请求是固定对应到具体DMA流上的。比如TIM2_CH1的捕获事件可能只对应DMA1的某个流你如果随手选了一个流初始化不报错信号来了缓冲区纹丝不动查起来特别费劲。换到G4或L4系列带DMAMUX的芯片必须显式把DMAMUX的请求源设置为定时器捕获事件否则DMA不知道去向。这些关系芯片参考手册里都写得很清楚配置前花两分钟查表比事后抓瞎强得多。第二个坑是HAL库DMA回调的重复触发。HAL_TIM_IC_CaptureCpltCallback这个回调在标准库和HAL库里的触发条件有细微差别。我在调试时遇到过一次数据块被重复解析的情况后来发现是两个通道的DMA都配置了传输完成中断两个完成中断都触发了同一个电容回调导致的。解决方式就是前面说的只在其中一个通道的DMA中断里做解析另一个通道完全交给硬件搬运。第三个坑是中断优先级。DMA的传输过半中断和传输完成中断优先级要高于定时器的溢出中断因为DMA中断里要读取overflow_count的值如果溢出中断优先级太高在DMA中断执行过程中又插入溢出中断overflow_count被修改时间戳的修正就可能出错。我习惯把DMA中断优先级设为2定时器溢出中断设为3让DMA中断不被嵌套打扰。第四个坑比较隐蔽是编译器优化。overflow_count、rising_timestamps这类在中断里修改、在主循环里读取的变量一定要加volatile修饰。否则编译器可能把变量缓存到寄存器里主循环读到的还是旧值脉宽值就一直不对。这种问题最折磨人因为代码逻辑看不出任何毛病就是数值不对后来加了volatile立刻恢复正常。6. 实测效果、误差分析与扩展方向6.1 实测效果用信号发生器输出1kHz、50%占空比的方波做基准测试定时器时钟84MHz理论分辨率11.9ns。跑起来后每批数据里256个脉宽值我统计了一下大部分脉宽值集中在500.00微秒附近波动幅度大概在±1到2个tick也就是几十纳秒级别。这个波动主要来自信号发生器本身的边沿抖动不是测量系统的误差。连续运行一小时后没有出现数据错位、缓冲区覆盖导致的计算错误CPU占用率我估算了一下处理256个脉宽数据用了不到几毫秒而且是在DMA中断里一次性处理的对主循环几乎零影响。相比之前用中断方式测同样信号CPU占用降低了一个数量级以上测量方差也明显更小。我还拿了一块高精度频率计做交叉验证把脉宽值和占空比换算成频率与频率计对比两者的差异在0.01%以内。这个误差主要来源于STM32的HSE晶振精度一般8MHz晶振误差在20ppm左右换算到84MHz之后还是能保持这个量级的精度。如果对精度有更高要求可以使用TCXO温补晶振或者外部高精度时钟源作为定时器时钟。6.2 误差来源详细拆解脉冲宽度计的误差主要分三类时钟误差、触发误差和量化误差。时钟误差来自晶振本身的频率偏差和温漂它会成比例地影响所有测量结果属于系统性误差。解决办法就是提高基准时钟精度或者做软件校准用一个已知精度的信号源测一遍算出修正系数之后所有测量值乘以这个系数。我做了个简单的两点校准修正后误差能压到几十ppm以内。触发误差来自信号边沿本身的抖动和引脚输入触发器的不确定性。信号边沿不是理想的瞬时跳变输入触发器也有一个和时钟沿竞争的窗口边沿落在窗口里时锁存的时间戳可能相差一个计数周期。这个误差对每个测量结果至多影响1个tick可以通过多次测量取平均来减小。量化误差是时间戳最小单位带来的固有误差就是前面说的一个tick的时间提高计数频率可以直接减小这个误差。三者合在一起就是我实测看到的几十纳秒量级的波动这套方案在大多数工程场景里精度已经完全够用。6.3 可持续扩展的方向这套方案做出来后扩展价值比单纯测脉宽本身要大得多。首先既然两个通道分别记录了上升沿和下降沿时间戳那么相邻两个上升沿之间的差值就是脉冲周期周期倒数就是频率所以这套电路天然就是“脉宽周期频率占空比”四合一的测量模块只需要在数据处理层增加一句周期计算输出信息量翻倍。其次如果想做更高速的信号测量可以用定时器的从模式做外部时钟计数把待测信号直接作为定时器时钟输入这样分辨率完全摆脱了内部时钟限制可以达到信号上升沿级别的测量。不过这需要把定时器配置从内部时钟模式改为外部时钟模式逻辑上又是一套新玩法。再或者可以把捕获到的连续时间戳流通过串口或USB发到上位机在上位机里做脉冲串分析、抖动分析、占空比统计。DMA搬运的时间戳天然是一段连续记录非常适合做这种后处理。我之前还试过把这段数据流接到Python程序里生成脉冲宽度的直方图效果非常直观。我个人的体会是这套“双输入捕获DMA”的设计最值钱的部分不在代码本身而在于“让硬件处理时间关键事件、让软件处理数据运算”这个思路。STM32里很多外设都有类似的硬件加速机制DMA也不是只有串口才能用多想想怎么把外设联动起来做出来的东西不管是精度还是资源占用都会比全塞进中断里高出一个档次。最后再说一个实际经验把DMA缓冲区里的数据用串口打印到PC自己写个小脚本画时间戳分布图调试速度和信心会提升得非常明显。
返回列表