
脉冲宽度测量这个东西做嵌入式的应该都绕不开。不管是测PWM信号、传感器输出、遥控接收机信号还是分析通信波形本质都是要精准抓到上升沿和下降沿的时间戳再把差值算出来。以前我用外部中断加定时器计数器做过一版低频还行频率一高、信号一密CPU中断负载直接拉满系统其他任务都没法跑。后来换成 STM32 的输入捕获加 DMA才算是彻底解放了CPU精度也上了一个台阶。这套方案的核心思路是利用定时器输入捕获通道自动记录边沿事件再用 DMA 把捕获值搬到内存里整个过程不需要 CPU 干预。连续测上几百个脉冲宽度CPU 只负责最后做减法算结果。今天这篇笔记就把双输入捕获加 DMA 的完整思路、寄存器配置、Cubemx 初始化流程和实测心得一次讲透适合用过 STM32 定时器但没尝试过“捕获DMA”组合的开发者。1. 内容整体设计与思路拆解1.1 为什么选双输入捕获而不是单通道单通道输入捕获也能测脉冲宽度思路是先捕获上升沿再捕获下降沿两次捕获值的差值就是高电平时间。听起来很简单但实际使用有一个很麻烦的问题边沿配置需要动态切换。你要在上升沿中断里把捕获边沿改成下降沿在下降沿中断里再改回上升沿来回折腾。这个操作本身消耗不少 CPU 时间而且边沿切换瞬间很容易漏掉极其窄的脉冲尤其是占空比很小或很大的时候误差会被放大得很难看。双输入捕获就聪明很多。它用两个通道同时盯着同一个信号源一个通道配置成上升沿捕获另一个配置成下降沿捕获。这样边沿检测由硬件并行完成完全不需要在中断里改配置。我常用的做法是通道1捕获上升沿通道2捕获下降沿两个通道的捕获值都会被 DMA 自动搬到内存数组。主程序只需要每隔一段时间把两个数组对应位置的值做一次减法就能得到完整的高电平时间。配合定时器的溢出中断做计数扩展还能测很宽的脉冲范围。1.2 DMA 在这里扮演的角色很多人不理解为什么输入捕获要配 DMA。其实输入捕获本身不占 CPU因为边沿检测是硬件完成的触发时会把 CNT 的值锁存到 CCR 寄存器。但如果不用 DMA你就必须在捕获中断里读 CCR否则下一次捕获一到来CCR 就会被覆盖。中断频繁触发CPU 就被拖死了。DMA 相当于在 CCR 和内存之间开了一条直通车道。每次 CCR 更新DMA 就自动把数据搬到内存数组里搬运完成后可以触发一次中断告知 CPU。这样 CPU 既不用频繁进中断也不会丢数据。配合定时器的 DMA 突发请求甚至可以一次触发搬运一批数据效率更高。在实际项目中我用的是 DMA 循环模式内存开一个环形缓冲区一边搬一边算持续测量不中断。1.3 高精度的关键时基与溢出处理要谈高精度最重要的就是时基。STM32 定时器的计数时钟通常来自内部时钟源经过预分频器后得到。假如系统主频 72MHz预分频设为 72-1那么定时器计数频率就是 1MHz每个计数单位代表 1 微秒。这样测出来的脉冲宽度精度就是 1 微秒。如果设成 72MHz 不分频精度就能到 1/72 微秒大概 13.9 纳秒。但要注意计数频率越高定时器溢出就越快16 位定时器最多计 65535 个数。所以精度和量程是一对矛盾需要根据实际信号频率来平衡。溢出处理是另一个容易翻车的地方。16 位定时器计满后会从 0 重新开始如果没有处理溢出当捕获发生在溢出后不久时你把两次捕获值直接相减会得到一个负数或者一个错误的差值。正确的做法是开启更新中断或者用 DMA 把溢出标志也记录下来。更优雅的方案是把定时器配成 32 位模式像 STM32F1 的高级定时器 TIM1/TIM8 以及 F4 的 TIM2/TIM5 都是 32 位计数器溢出周期长得多很多场景下根本不用考虑溢出问题。但如果是 16 位定时器就得认真规划溢出补偿逻辑。2. 核心细节解析与实操要点2.1 定时器与通道选择STM32 家族里不是所有定时器都能配 DMA 输入捕获。我这边拿最常用的 STM32F103 举例它有 TIM1、TIM2、TIM3、TIM4其中 TIM1 是高级定时器其他是通用定时器。TIM1 和 TIM2 的捕获/比较通道可以触发 DMA 请求TIM3 和 TIM4 也能触发但 DMA 映射关系略有不同。具体要查芯片参考手册里的 DMA 请求映射表千万别想当然。理论上任意两个通道都能做双输入捕获比如 TIM2 的 CH1 和 CH2。实际使用时要注意这两个通道对应的引脚必须具备相同的定时器复用功能。例如 PA0 对应 TIM2_CH1PA1 对应 TIM2_CH2它们可以一起配置。这样一来硬件连接上只需要把被测信号同时接到 PA0 和 PA1 两个引脚即可。如果想省引脚有些系列支持同一引脚的映射模式比如 STM32G4 系列可以把同一个外部信号通过 TI1FP1 和 TI2FP1 等内部路径映射到不同通道但标准库和 HAL 用起来稍麻烦入门先别折腾。我建议初学者先选 F103 的 TIM2因为 TIM2 是 32 位定时器溢出问题少而且 DMA 请求在 DMA1 的通道 6 和通道 7 上映射清楚。等把基本流程跑通了再换其他定时器。2.2 分频系数、ARR 与脉冲量程的关系这里要记一个非常核心的公式脉冲宽度时间 (捕获值差值) × (分频系数 1) / 定时器时钟频率如果定时器时钟是 72MHz预分频 PSC 71那么定时器计数频率 72MHz / (711) 1MHz。此时每个计数单位 1us。假如捕获值差值是 1500那么高电平时间就是 1500us即 1.5ms。如果 PSC 0则每个计数单位 1/72us ≈ 13.89ns。但是量程受 ARR 限制。16 位定时器 ARR 最大 65535在 1MHz 计数频率下最大可测时间约 65.535ms。如果信号频率太低比如 1Hz 的 PWM高电平时间可能有 500ms这时候 16 位定时器直接爆掉。三种解法用 32 位定时器TIM2/TIM5最大计数到 4.29 亿1MHz 下可测约 429 秒足够了。保持 16 位定时器但把计数频率降低也就是加大 PSC不过精度会下降。在更新中断里给一个软件扩展计数器每次溢出加 65536然后通过软件组合出高 16 位和低 16 位。这种方法精度不受影响但中断会稍微占用一点资源。我在实际项目中更推荐方案 1因为 STM32 的 TIM2 实在太好用了直接在 CubeMX 里勾选内部时钟ARR 填 0xFFFFFFFF 就行。2.3 DMA 模式选择与内存布局DMA 有两种模式比较适合输入捕获普通模式和循环模式。普通模式下DMA 搬运完设定数量的数据后自动停止需要软件重新使能。循环模式则是一圈一圈地搬适合连续采样。内存数组建议用环形缓冲区的思路。比如定义一个数组uint32_t rise_buf[1024];和uint32_t fall_buf[1024];DMA 循环把捕获值写入数组。DMA 传输完成中断里记录当前 DMA 的当前数据指针位置和上一次的位置做比较就知道新来了多少数据。但这里有个细节如果 DMA 正搬到一半你又在中断里做减法读到的值可能是一半新一半旧。为了完全避免数据撕裂常用的办法是DMA 用循环模式传输完成中断触发一次处理在处理函数中先读 DMA-NDTR剩余传输计数算出已经传输了多少个数据然后再从数组里取最新的 N 个值去做差分处理完后更新上次读取的位置。这种方式只要保证处理时间小于 DMA 填满一圈的时间就不会丢数据。如果数据量很大可以开更大的数组或者使用双缓冲。另外如果使用 HAL 库的HAL_TIM_IC_Start_DMA它的回调函数里要特别注意参数HAL_TIM_IC_CaptureCallback会传给你当前捕获值。实际上使用 DMA 时HAL 已经把数据搬到了你指定的缓冲区回调里你可以直接读缓冲区但要注意 HAL 库在 DMA 模式下会默认把 DMA 设为普通模式需要在初始化之后手动改成循环模式。这个坑我踩过好多次下文会专门讲。3. 实操过程与核心环节实现3.1 CubeMX 配置步骤我用 STM32CubeMX 配置比较多因为它直观、不易漏项。以 STM32F103C8T6 为例外部晶振 8MHz系统时钟跑到 72MHz目标测量 1kHz 左右的 PWM 信号精度要求 1us 以内。先配置 RCC选择 HSE 外部晶振然后在 Clock Configuration 里把 HCLK 设为 72MHz。之后找到 TIM2在 TIM2 Mode and Configuration 页面按下图思路设置Clock Source: Internal ClockChannel1: Input Capture direct modeChannel2: Input Capture direct modePrescaler: 71Counter Period: 0xFFFFFFFF因为 TIM2 是 32 位auto-reload preload: EnableInput Filter: 0也可以设小一点比如 4用来滤除毛刺但会增加响应延迟然后在 GPIO Settings 里确认 PA0 和 PA1 被自动配置为输入模式并且复用功能选择 TIM2_CH1 和 TIM2_CH2。这里有个关键点输入捕获引脚的 GPIO 模式应该选择输入模式而不是复用推挽输出。CubeMX 会自动处理但如果手动写代码千万别搞错。接着配置 DMA。在 TIM2 的 DMA Settings 标签页中添加两个 DMA 请求TIM2_CH1 触发 DMA方向外设到内存外设地址固定内存地址递增数据宽度都是字32位模式选择 Circular。TIM2_CH2 触发 DMA方向外设到内存同样字宽循环模式。注意如果 CubeMX 在添加 DMA 时默认是 Normal一定要手动改成 Circular。HAL 库的HAL_TIM_IC_Start_DMA函数内部确实会把 DMA 配置成 Normal这里需要在自己代码里重新覆盖。3.2 代码实现初始化与开启捕获CubeMX 生成工程后主要补代码的地方在main.c和stm32f1xx_hal_tim.c的回调里。全局变量如下#define BUF_SIZE 1024 volatile uint32_t rise_buf[BUF_SIZE]; volatile uint32_t fall_buf[BUF_SIZE]; volatile uint32_t dma_rx_index 0; volatile uint8_t dma_first_half 0;有些 HAL 例程会用HAL_ADC_ConvCpltCallback形式的回调而定时器输入捕获 DMA 对应的回调是void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { // 在循环 DMA 模式下HAL 会频繁调用这个回调 // 我们可以在这里更新接收计数信息 } }但更直接的读位置方式是直接读 DMA 通道的 NDTR。这里我用标准库的方式来做说明逻辑更透明。初始化开启捕获void pulse_meter_init(void) { HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t *)rise_buf, BUF_SIZE); HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_2, (uint32_t *)fall_buf, BUF_SIZE); }注意HAL_TIM_IC_Start_DMA 每次调用之后它内部会把 DMA 设置为 Normal 模式。所以如果你要用循环模式必须在这两句之后重新设置 DMA 的 Mode 并重新使能。__HAL_DMA_DISABLE(hdma_tim2_ch1); __HAL_DMA_DISABLE(hdma_tim2_ch2); hdma_tim2_ch1.Init.Mode DMA_CIRCULAR; hdma_tim2_ch2.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma_tim2_ch1); HAL_DMA_Init(hdma_tim2_ch2);其实更干净的做法是在 CubeMX 生成后直接修改HAL_TIM_IC_Start_DMA的实现但那个文件是自动生成的升级 CubeMX 的时候会覆盖。所以我建议只在用户代码里操作。3.3 数据处理差值计算与周期还原数据上来之后关键就是如何在正确的内存位置做差值。直接拿数组第 0 个上升沿和第 0 个下降沿相减是不对的因为两个通道捕获的时序是独立的DMA 搬运的进度也不一定同步。必须保证“第 n 个下降沿”和“第 n 个上升沿”是同一个脉冲的两个边沿。对着时序图就清楚了第一个上升沿先到触发 CH1 捕获并把值放到 rise_buf[0]随后下降沿到来触发 CH2 捕获放到 fall_buf[0]。所以 Fall_buf 的第一个值对应的就是第一个高电平结束时刻。因此差值为uint32_t high_ticks fall_buf[i] - rise_buf[i];float pulse_width_us (float)high_ticks; // PSC71, 约1MHz所以1 tick1us如果是 1MHz 计数频率差值就是微秒数。如果精度要求更高比如配到了 72MHz 不分频就要按公式换算成时间 c float pulse_width_us (float)high_ticks / 72.0f;还有一点如果被测信号的高电平持续时间超过了 ARR 溢出周期两次捕获值本身可能都是重新计数后的值直接相减就会出错。TIM2 是 32 位溢出周期很长一般不会遇到。但如果你用 16 位定时器就得加上溢出补偿uint32_t diff; if (fall_buf[i] rise_buf[i]) { diff fall_buf[i] - rise_buf[i]; } else { diff (0x10000 - rise_buf[i]) fall_buf[i]; // 16位定时器 }这里0x10000是 65536对应 16 位定时器溢出回绕。如果 ARR 不是 65535 而是其他值就要用ARR 1代替。3.4 环形缓冲区读取与数据连续性我实际项目中用的是阻塞式主循环处理主循环检查 DMA 是否有新数据有就处理一批处理完清理标记。这种方式比较直观。伪代码while (1) { uint32_t cur_ndtr __HAL_DMA_GET_COUNTER(hdma_tim2_ch1); uint32_t received BUF_SIZE - cur_ndtr; // 新数据数量 当前累计接收数 - 上一次处理的累计数 if (received last_pos 2) { // 防止越界一律做环形寻址 for (uint32_t i last_pos; i received; i) { uint32_t h rise_buf[i % BUF_SIZE]; uint32_t l fall_buf[i % BUF_SIZE]; process_pulse(h, l); } last_pos received; } }这种做法的好处是不依赖中断回调代码在无 RTOS 的裸机上很稳定。但要注意虽然 TIM2_CH1 和 TIM2_CH2 的 DMA 是独立搬运上升沿和下降沿交替出现时两个缓冲区的写入速率是相同的只是可能有几个数据的前后错位。使用同样的last_pos读取两个缓冲区时可能会出现 fall_buf 还没写入第 i 个数据而 rise_buf 已经写了。最简单的避免方案是在循环读取时先读两个 DMA 的 NDTR看两个缓冲区已经接收的数据量然后取较小值作为安全边界。比如uint32_t ndtr1 __HAL_DMA_GET_COUNTER(hdma_tim2_ch1); uint32_t ndtr2 __HAL_DMA_GET_COUNTER(hdma_tim2_ch2); uint32_t received1 BUF_SIZE - ndtr1; uint32_t received2 BUF_SIZE - ndtr2; uint32_t safe_count (received1 received2) ? received1 : received2;这样处理之后读取到的两个缓冲区数据在时间上是对齐的不会出现“拿第 5 个上升沿对齐第 5 个下降沿”时其实下降沿才写到第 4 个的情况。4. 常见问题与排查技巧实录4.1 DMA 搬运指针不动或只有第一次有效这是我在论坛问答里见到最多的问题。情况是串口打印发现 rise_buf 只更新前几个数据之后就不动了。原因十有八九是 DMA 被设成了普通模式Normal。HAL_TIM_IC_Start_DMA内部源码里会把 DMA 设置为 Normal所以你必须像上文那样手动改成循环模式。还有一个隐藏陷阱如果你在 CubeMX 里把 DMA 模式配成 Circular但生成代码后又调用了HAL_TIM_IC_Start_DMA这个函数会把 DMA 的 Mode 重新改回 Normal。所以最稳妥的做法是不要在初始化时调用这个函数而是手动配置 DMA 后直接调用HAL_TIM_IC_Start_DMA再紧接着覆盖一次。或者说直接绕过 HAL 函数使用HAL_DMA_Start_IT配合定时器的 DMA 请求使能位。更直接的方法是使用标准外设库void TIM2_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel6); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)TIM2-CCR1; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rise_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Word; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Word; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel6, DMA_InitStructure); DMA_Cmd(DMA1_Channel6, ENABLE); }通道 7 对应 CH2 的 CCR2同样配置。然后使能定时器的 DMA 请求TIM_DMACmd(TIM2, TIM_DMA_CC1, ENABLE); TIM_DMACmd(TIM2, TIM_DMA_CC2, ENABLE); TIM_Cmd(TIM2, ENABLE);这种方式更加可控。使用 HAL 也没问题但你要深刻理解它改动了什么。4.2 捕获值跳变偶尔出现 0 或者极大值如果你打印捕获值发现绝大部分正常但偶发 0 或 0xFFFFFFFF大概率是信号边沿抖动导致重复触发。比如输入信号带毛刺上升沿出现一次真实的随后因为振铃又出现一次上升沿。第一次捕获值是真值第二次捕获值几乎一样但在极端情况下捕获 DMA 写入数组后你可能读到中间值。解决办法是使用输入滤波器。STM32 定时器的捕获通道都配有数字滤波器可以过滤掉小于设定时钟周期的脉冲毛刺。寄存器里IC1F[3:0]可以设置滤波长度。CubeMX 中对应 Channel1 的 Input Filter 参数通常设为 4 到 8 即可。滤波会引入一点点延迟大约几个计数周期但换来的是更好的稳定性。另一个原因是 DMA 的传输宽度和外设寄存器不匹配。TIM2 的 CCR 是 32 位寄存器如果你把 DMA 数据宽度配成 Half Word16位它只搬运寄存器低 16 位高 16 位永远为 0。输出结果会鬼一样地跳。务必确认外设数据宽度 字32位内存数据宽度 字32位内存递增使能4.3 双通道捕获的时间戳不是同一时刻有朋友问既然两个 DMA 是独立的会不会有下降沿比上升沿先搬运到内存答案是搬运顺序可能颠倒但捕获值的产生顺序是由硬件边沿触发顺序决定的永远是先上升后下降。即使 DMA 把下降沿数据先送到了内存只要你在处理后端使用“取两者都安全的数据范围”的方式算出来的差值是没问题的。但如果你的信号是低电平脉冲即本来空闲是高电平触发信号来时拉低然后恢复高电平。这时候应该考虑“下降沿先捕获上升沿后捕获”。双输入捕获方案里你需要判断哪个通道先触发。我建议在应用层加一个状态标记每收到一对数据时先比较通道 1 和通道 2 捕获值的时间先后。如果信号极性固定就不需要动态判断直接按固定方式计算即可。4.4 测量高频率信号时的注意事项当测量频率非常高比如 1MHz 的 PWM高电平时间只有 500ns即 0.5us。如果用 1MHz 计数频率一个周期只有不到 1 个 tick根本测不准。这时必须把预分频设为 0用 72MHz 计数每个 tick 约 13.9ns。这时候要格外注意定时器溢出72MHz 下 TIM2 是 32 位能连续测约 59.6 秒才溢出一次基本放心。还要注意 DMA 的带宽。72MHz 下如果信号频率 1MHz每秒会产生 200 万个边沿事件也就是每秒 DMA 需要搬运 200 万个 32 位数据数据传输速率约 8MB/s。这个量级对 STM32 的 DMA 来说并不是问题但如果你在中断里做了大量计算可能来不及导致 DMA 覆盖旧数据。此时应该简化处理函数只做差分和暂存把复杂的统计分析放到后台慢慢做。4.5 排查问题时的调试技巧我调试这种捕获流程时习惯先把数据直接通过串口打印出来肉眼观察。打印数据量大时可以只打印前 100 个捕获值或者打印一段时间内的最小、最大、平均值。另外强烈建议在调试阶段使用逻辑分析仪或者另一块板子生成标准 PWM 信号把测量结果和逻辑分析仪读数对比。如果条件允许把 DMA 中断打开在中断里翻转一个 GPIO用示波器看这个 GPIO 的频率和占空比就能判断 DMA 处理速率是否跟得上。这个方法比打印日志直观得多。5. 扩展思路与进阶玩法5.1 同时测量频率、占空比与脉宽双输入捕获的天然优势是能同时拿到上升沿时刻和下降沿时刻所以不仅能算高电平脉宽还能算周期相邻两个上升沿的差值即rise_buf[i] - rise_buf[i-1]倒数就是频率占空比高电平时间 / 周期时间低电平时间rise_buf[i1] - fall_buf[i]。这样一套数据下来PWM 波形的基本参数全齐了。我之前做一个电机调速器的反馈监测就是用这套方式实时读取遥控器 PWM 信号的频率和占空比CPU 负载几乎可以忽略不计。5.2 结合外部捕获引脚做多路测量如果觉得双通道不够用还可以把两路独立信号分别接到 TIM 的不同通道。比如 TIM2 的 CH1 测信号 ACH2 测信号 B这样同一时间片可以测两路频率互不干扰。或者更复杂一点用 TIM3 再多做一路捕获。只要 DMA 请求通道不冲突资源足够路数可以叠加。但要注意DMA 控制器本身有通道数量限制F103 的 DMA1 有 7 个通道DMA2 有 5 个通道。如果外设很多要提前规划好 DMA 分配避免优先级低的 DMA 请求被饿死。5.3 从脉冲宽度测量到 PWM 解码做遥控航模的人应该很熟悉接收机输出的就是 1ms 到 2ms 的脉宽信号对应不同的油门或舵量。用这套双输入捕获方案做 PWM 解码比传统的中断计时稳定太多。我实际测试下来1us 精度完全无压力如果使用 72MHz 计数能达到约 14ns 的理论分辨率对遥控器来说绰绰有余。解码逻辑再往上抽象一层可以写成状态机。每次完成一次捕获差分就把脉宽值解析成对应的通道值放到共享内存里。另一个任务比如 PID 运算直接读取这个共享值就行。这样测量和解算解耦代码扩展性很好。5.4 低功耗场景下的处理建议如果项目要求低功耗DMA 加捕获的组合仍然适用。因为 CPU 大部分时间在睡眠DMA 搬运不依赖 CPU定时器使用外设时钟能耗比中断方式低不少。但需要关注DMA 搬运完成中断可以把 CPU 从 STOP 模式唤醒但唤醒有延迟可能导致漏掉唤醒后的第一次捕获。解决办法是在进入睡眠前确保 DMA 缓冲区有足够余量唤醒后先处理新数据再决定是否继续睡眠。更具体地说可以使用HAL_TIM_IC_CaptureCallback里的 DMA 中断来唤醒但这个回调可能太频繁。我是用 DMA 传输过半中断Transfer Complete Interrupt或者一个缓冲区的填满中断来唤醒降低唤醒频率效果更好。6. 数据可靠性设计补充6.1 校验边沿匹配关系实际信号如果出现干扰可能会产生异常边沿。比如上升沿之后紧接着一个高电平毛刺下降沿就被提前触发了。这种情况下fall_buf[i] - rise_buf[i]可能是一个极小值。合理的做法是在后处理时加一个脉宽合理范围判断if (high_ticks min_valid_ticks || high_ticks max_valid_ticks) { // 丢弃这组数据标记异常 }阈值可以从信号的标称频率和占空比范围推算出来。比如要测 1kHz、占空比 10%-90% 的 PWM高电平时间就在 100us 到 900us 之间CPU 只需要保留这个区间之外的数据做统计超界即视为干扰或异常。6.2 DMA 覆盖旧数据时的处理策略循环 DMA 最大的风险是缓冲区被写满后从头开始写如果 CPU 来不及读取旧数据就会被覆盖。此时程序不会报错但会静默丢数据。解决策略除了加大缓冲区还有一种方法是监控 DMA 的当前数据指针变化。每次读取时记录上次的位置如果发现最新位置已经越过了上一批数据的末尾因为缓冲区回绕说明已经出现了覆盖。这个现象可以通过计算received - last_pos是负数或者超过一定阈值来判断。对于时间要求极高的场景建议使用双缓冲机制DMA 把数据先搬到缓冲区前半触发半传输中断CPU 处理前半部分DMA 继续写后半部分再触发传输完成中断CPU 处理后半部分。这样永远有一半缓冲是空闲的不会覆盖。HAL 库里没有直接封装这种“半传输中断 完成中断”的定时器捕获回调但可以用HAL_DMA_RegisterCallback注册自定义回调来操作。裸机代码反而好写一些直接操作 DMA 寄存器即可。6.3 长时间运行的温度漂移问题如果项目在工业环境下运行晶振或内部 RC 振荡器的频率会随温度漂移。STM32 内部 RC 的精度在 ±1% 左右这对一般测量够用但如果测出的脉宽要当控制量用1% 误差可能完全不可接受。建议外部接 8MHz 晶振并确认 RCC 配置使用的是 HSE。另外如果系统有网络模块或者高精度时钟源可以通过外部 1PPS 秒脉冲做自动校准实时修正计数频率。这个方案我还没在正式产品中用过但思路是可行的用秒脉冲的上升沿作为参考计算一秒钟内捕获到多少个 tick反推出实际计数频率再根据偏差修正脉宽值。7. 我对这套方案的真实评价做脉冲宽度测量最怕的就是复杂度和实时性的矛盾。双输入捕获加 DMA正好把最脏最累的实时数据搬运交给了硬件CPU 只需要处理事件后的数据架构非常舒服。老实说我第一次调通的时候看到那串稳定的脉宽数据真有一种“还能这样玩”的惊喜感。但要说有没有遗憾也有。HAL 库的门槛确实不低尤其是 DMA 模式的切换、回调函数的时机如果对底层不熟很容易被搞得很懵。我的建议是做这类偏底层的测量任务别完全依赖 CubeMX 生成的代码至少要把 DMA 通道、定时器请求映射还有 CCR 寄存器的位宽彻底搞清楚。文档多翻几遍踩坑就能少很多。后续如果你要做更复杂的多通道测量可以试试 STM32 的 ADC 加 DMA 结合定时器触发甚至做 DMA 的多缓冲 ping-pong 传输玩法更多。希望这篇笔记能帮你在脉冲测量这条路上少走弯路把时间花在真正有价值的算法和业务逻辑上。