ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32输入捕获测脉宽:从原理到CubeMX配置与HAL库实现

2026/10/5 3:57:46 拓冰建站 浏览量
STM32输入捕获测脉宽:从原理到CubeMX配置与HAL库实现 脉宽测量这话题做嵌入式的十有八九都碰过。遥控器信号解析、舵机占空比检测、PWM 输出回读校验甚至测电机转速的脉冲宽度说到底都是在问同一个问题这个高电平到底持续了多久。做方案的人也不少有人直接拿外部中断加一个定时器软计时也有人直接上逻辑分析仪但在 MCU 内部用定时器的硬件输入捕获才是精度和实时性都划算的做法。这篇是输入捕获系列的第六篇专门把“脉宽测量”这件事掰开讲从输入捕获原理到 CubeMX 参数计算再到 HAL 库中断回调里的状态机实现最后把我实际调试中踩过的坑提前给你排一遍。适合手里有 STM32F4 开发板、想用正经方式测信号时间的同学也适合已经跑通例程但搞不清楚参数怎么调的人。1. 输入捕获测脉宽到底测的是什么1.1 一句话讲清楚输入捕获定时器里最核心的就是 CNT计数器、PSC预分频器和 ARR自动重载值这三个寄存器。CNT 按预分频之后的时钟不断加一ARR 决定加到哪里再翻回零重新来。拿生活里的事情打比方ARR 就是表盘的刻度上限CNT 就是当前指针指到哪。输入捕获干的事情就是在 CNT 正常跑数的过程中给计数器装了一个“按快门”的功能当输入引脚检测到指定边沿时硬件立刻把 CNT 的当前值复制一份到捕获/比较寄存器 CCR同时通知 CPU。注意一个关键点这个“复制”动作是纯硬件完成的不需要 CPU 参与所以锁存的时刻非常准误差只来自时钟本身的精度和边沿检测电路做同步化时引入的微小延迟。很多人一上来就纠结中断里代码是不是够快其实输入捕获和这个关系不大。硬件锁存发生在边沿瞬间中断只是来通知“你赶紧把 CCR 里的数据取走”你早几微秒取、晚几微秒取都不影响锁存那一刻对应的真实时间点。这一点是整个测量方案的地基后边所有精度分析都围绕它展开。1.2 和外部中断软计时法有什么区别有朋友会问那我用 EXTI 外部中断在中断里读 HAL_GetTick()不是也能测脉宽吗能测但硬伤在于中断响应的不确定性。外部中断进入中断服务函数、读取时间戳这中间隔着中断延迟、压栈、总线访问时间每次都未必一样误差几微秒起步。信号快一点、频率高一点这个方案就明显吃力了。输入捕获则没有这个问题。硬件在边沿那一刻就把计数器拍进 CCR中断闹钟响了之后你去取取到的是早就锁存好的“历史快照”。所以哪怕系统负载很高、中断被稍微延迟只要别延迟到定时器溢出好几轮测量数据本身依然是准的。这就是为什么正经测量类项目都优先选输入捕获。1.3 测脉宽最常见的两条技术路线实际写代码时测高电平脉宽有两种主流做法我先做个对比后边代码部分会按路线 A 展开。路线 A 是两个边沿各自捕获、后值减前值。上升沿来的时候读一次 CCR记成 t1下降沿来的时候再读一次 CCR记成 t2高电平时间就等于 t2 减 t1。因为两次读数都是硬件在边沿瞬间锁存的中断延迟不会影响结果精度最好。代价是要处理两次捕获之间定时器溢出的问题。路线 B 是“清零法”。上升沿触发中断后CPU 手动把 CNT 清零等下降沿来的时候CCR 里的值直接就是高电平时长。这样中短代码非常直观省去了相减的步骤。但要注意手动清零这个动作发生在中断响应之后也就是说清零的时刻比真实上升沿晚了零点几到几微秒测出来的值会系统性偏小这么一点。对舵机信号这种毫秒级别的东西无所谓做微秒级精密测量就不太合适了。对比项路线A两次读取相减路线B捕获后清零精度高硬件锁存边沿瞬间略低中断清零有系统性偏差代码复杂度稍复杂需处理溢出简单直观适用场景高频、高精度测量毫秒级常规 PWM 测量1.4 分辨率和量程本质上是一对矛盾配置输入捕获之前最好先算一笔账。定时器计数频率越高每个 tick 代表的时间越短分辨率越高但 ARR 是固定的话溢出就越快单次测量能覆盖的时间范围就越短。两个公式是绕不开的计数频率 定时器时钟 /PSC 1单个 tick 时间 1 / 计数频率最大单帧时间 ARR 1× 单个 tick 时间以 84MHz 定时器时钟为例。PSC 设 83计数频率就是 1MHz一个 tick 是 1us16 位定时器 ARR65535 时最大能测 65.535 毫秒PSC 设 0计数频率 84MHz一个 tick 约 11.9 纳秒但最大测量范围只剩约 780 微秒。所以千万别指望一套参数通吃所有信号。测窄脉冲、高频信号尽量拉高计数频率测 50Hz 舵机信号这种周期 20 毫秒的东西就得压低计数频率或者换 32 位定时器。真要在一条白纸上写结论先确认你要测的脉宽大概是什么量级再定 PSC顺序不能反。2. CubeMX 配置与参数计算2.1 选定时器前先搞清楚时钟树这次用的板子是 STM32F407VET6HAL 库配套 CubeMX。先把时钟树捋一遍SYSCLK 168MHzAHB 分频后 168MHzAPB1 是 42MHzAPB2 是 84MHz。很多人在这里栽过跟头明明 PSC 写的是 83怎么计数频率不是自己想当然的那个数原因出在定时器时钟的倍频规则上。STM32 的规则是当 APB 分频系数不等于 1 时挂在对应总线上的定时器时钟会翻倍。APB1 分频 /4所以挂在 APB1 上的 TIM3~TIM7 实际时钟是 42×284MHzAPB2 分频 /2TIM1、TIM8 的实际时钟是 84×2168MHz。所以你在 CubeMX 的 Clock Configuration 页面里会看到 Timer3 clock 显示成 84MHz。这个数字是后面计算 PSC 的地基搞错了全盘皆输。2.2 参数计算1us 分辨率的完整推导做一个“测遥控器 PWM 高电平时长”的经典需求这类信号高电平一般 1 到 2 毫秒周期 20 毫秒。我想让分辨率达到 1us量程至少要覆盖 10 毫秒。定时器时钟 84MHz要达到 1us 一个 tick计数频率就得是 1MHz所以 PSC 84 ÷ 1 - 1 83。ARR 用 16 位定时器的最大值 65535最大单帧测量时间就是 65536us约 65.5 毫秒覆盖 20 毫秒的周期绰绰有余。CubeMX 里 TIM3 的配置这样填Clock Source 选 Internal ClockChannel1 选 Input Capture Direct ModePrescaler 填 83Counter Period 填 65535Auto-Reload Preload 选 EnableInput Filter 先填 0不加滤波初始极性选 Rising上升沿开始为什么不直接选 PWM Input 模式PWM Input 是硬件上把一个输入同时映射到两个通道专门用来测周期和占空比确实方便。但这次是输入捕获专题的第六篇而且手动状态机可以自由决定测高电平、测低电平、测周期、测单次脉冲灵活性高得多。PWM Input 模式适合标准占空比检测做通用测量工具还是状态机更顺手。2.3 GPIO 和中断配置的细节选好定时器之后引脚是自动分配出来的。TIM3_CH1 对应 PA6复用功能是 AF2。CubeMX 里把 PA6 点成绿色 Timer 功能即可GPIO 模式会变成 Alternate Function。上下拉要不要配取决于外部信号源。如果输入信号是推挽输出的方波连接关系干净No Pull 就行如果信号源是开漏输出或者杜邦线比较长、环境有干扰配一个 Pull-Up 能减少悬空误触发。输入模式下的 GPIO 速度不用管没有意义。NVIC 设置里要记得把 TIM3 global interrupt 勾上否则捕获中断根本不会触发。优先级我给 Preemption2、Sub0不跟 SysTick 抢也不用刻意给到最高。真到了需要极端实时性的场景更应该想的是怎么减少中断里的工作量而不是单纯把优先级调到最高。3. HAL 库代码实现启动、回调、状态机3.1 全局变量和启动函数在 main.c 的 USER CODE 区域定义一组全局变量。注意加到回调里会被中断上下文访问的变量声明成 volatile防止编译器优化出问题。volatile uint32_t g_rise_tick 0; volatile uint32_t g_fall_tick 0; volatile uint32_t g_high_ticks 0; volatile uint8_t g_edge_state 0; volatile uint8_t g_measure_ready 0;启动函数这样写。核心是先把计数器清回零再从上升沿开始等void InputCapture_Start(void) { __HAL_TIM_SET_COUNTER(htim3, 0); g_edge_state 0; g_measure_ready 0; __HAL_TIM_SET_CAPTUREPOLARITY(htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1); }这里最容易错的是 HAL 函数选错。要启动的是 HAL_TIM_IC_Start_IT不是 HAL_TIM_IC_Start。前者是“启动捕获同时开中断”后者只启动捕获、不开中断回调永远不会被调用。我见过好几个朋友在这俩函数上卡了半小时。3.2 捕获回调一个简单的两态状态机HAL 库里 HAL_TIM_IC_CaptureCallback 是一个弱函数CubeMX 生成的代码不会占用这个名字直接复制到 main.c 里重定义即可。void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM3) return; if (htim-Channel ! HAL_TIM_ACTIVE_CHANNEL_1) return; if (g_edge_state 0) { /* 上升沿到达记录当前计数器值 */ g_rise_tick HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); /* 立刻切换极性等待下降沿 */ __HAL_TIM_SET_CAPTUREPOLARITY(htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); g_edge_state 1; } else { /* 下降沿到达记录当前计数器值 */ g_fall_tick HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); /* 相减得到高电平 tick 数1MHz 时就是微秒 */ g_high_ticks g_fall_tick - g_rise_tick; g_measure_ready 1; /* 极性切回上升沿准备测下一轮 */ __HAL_TIM_SET_CAPTUREPOLARITY(htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); g_edge_state 0; } }第一次进入回调时捕获到的是上升沿HAL_TIM_ReadCapturedValue 读回来的其实是硬件在上升沿瞬间锁存的 CNT 值。紧接着把捕获极性切到下降沿状态置 1。等到下一次捕获中断进来必然是下降沿读出的值就是边沿对应的 CNT两者相减就是高电平持续时间。主循环里消费测量结果就行if (g_measure_ready) { g_measure_ready 0; printf(high %lu us\r\n, (unsigned long)g_high_ticks); }这里的限制也顺便说清楚如果被测脉宽太短比如小于 1us下降沿可能在中端处理上升沿的过程中就到了极性还没切过去下降沿就被漏掉了。这种极端窄脉冲要么用直接寄存器操作减少中断里的指令数要么干脆换双通道方案不要硬扛。3.3 周期和占空比同时测量怎么扩展很多时候不只要测高电平还要同时测出周期和占空比。那就把状态机从两态扩成三态等第一个上升沿、等下降沿、等第二个上升沿。void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM3) return; if (htim-Channel ! HAL_TIM_ACTIVE_CHANNEL_1) return; switch (g_edge_state) { case 0: /* 等第一个上升沿 */ g_t1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); g_edge_state 1; break; case 1: /* 等下降沿 */ g_t2 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(htim3, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); g_edge_state 2; break; case 2: /* 等第二个上升沿 */ g_t3 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); g_high_ticks g_t2 - g_t1; g_period_ticks g_t3 - g_t1; g_duty_percent (uint32_t)((uint64_t)g_high_ticks * 100u / g_period_ticks); g_measure_ready 1; g_edge_state 0; break; } }占空比计算时注意两个坑。一是先乘后除防止小数部分被吞掉二是在做乘法时用 uint64_t 中转避免整数溢出。比如某个定时器配置下计数范围比较大65536 乘以 100 已经有 6 位数量级了再大就容易超过 32 位上限。这个习惯养成了以后换 32 位定时器也不会踩雷。3.4 计数器溢出突破 65.5ms 上限前面的代码里如果高电平时间超过定时器最大量程计数器就会在两次边沿之间溢出翻零导致 g_fall_tick 小于 g_rise_tick计算结果直接变成一坨乱码。解决办法有三个按省事程度排序。第一推荐换 32 位定时器。STM32 的 TIM2 和 TIM5 是 32 位计数器84MHz 时钟下最大量程约 51 秒绝大多数应用根本用不到溢出处理代码量最少。第二是把 PSC 调大牺牲分辨率换量程。第三才是写溢出计数逻辑。如果一定要在 16 位定时器上做可以在更新中断里维护一个溢出计数器volatile uint32_t g_tim3_overflow 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { g_tim3_overflow; } }然后在捕获回调两边沿分别记录当时的溢出计数值。计算时取值范围加上溢出补偿uint32_t delta g_fall_tick - g_rise_tick; uint32_t ovfs g_ovf_fall - g_ovf_rise; g_high_ticks ovfs * 65536u (delta 0xFFFFu);这个写法在“下降沿 CNT 小于上升沿 CNT、中间刚好溢出一次”的情况下也是正确的。真正较真的场景比如边沿和溢出恰好挤在同一个时钟周期还要去处理 UIF 标志的竞态这里不展开实际工程很少触发到这么极端的时序。4. 实测数据与精度分析4.1 信号发生器实测记录配置跑通之后我用信号发生器输出方波分别测了 100Hz、1kHz、10kHz、100kHz 四个频率点占空比 50%。用 1MHz 和 84MHz 两种计数频率做了对比实测数据如下信号设定理论高电平1MHz 计数实测84MHz 计数实测100Hz / 50%5000us4999~5001us5000.03us 附近1kHz / 50%500us499~501us500.02us 附近10kHz / 50%50us50~51us50.01us 附近100kHz / 50%5us5~6us5.00us 附近可以看到 1MHz 分辨率下误差基本被 ±1us 的量化误差主导而 84MHz 分辨率下读数稳定很多。这个对比很有参考价值不要一上来就把 PSC 调到最大先看看你的信号频率再决定分辨率。4.2 误差到底从哪里来把误差拆开看主要有四个来源。时钟源误差是最容易被忽略的一个。芯片内部 HSI 振荡器在不同温度下误差可能到 1%~3%也就是说测 500us 的脉宽光因为时钟不准就可能偏好几个微秒。这不是测量逻辑的问题是时钟本身的系统性误差。换外部晶振 HSE 之后误差可以降到几十 ppm 级别。量化误差是绕不开的。计数频率 1MHz 时一个 tick 就是 1us测量结果天然会有 ±1us 的量化偏差。这个误差和信号相位有关没办法消除只能通过提高计数频率来减小。边沿同步延迟是硬件层面的细节。输入信号要先经过引脚同步电路定时器时钟的上升沿去采样这个信号所以检测到边沿的实际时刻可能比真实边沿晚了 0 到 1 个时钟周期。84MHz 时钟下最多 12ns 左右大多数场景可以忽略做极端高精度测量时心里有数就好。最后就是外部信号本身的问题。信号发生器输出干净方波当然好实际项目里的信号可能带振铃、带噪声、接触不良边沿附近来回跳变。这种情况测量值会忽大忽小需要配合施密特整形电路或者在 CubeMX 的 Input Filter 里设置滤波档位。4.3 让测量更稳的几个实操建议第一能用外部晶振就别用 HSI。成本上只多一个晶振精度却差了几十倍这笔账怎么都划算。第二计数频率不要拍脑袋填。先拿示波器看一眼信号估算脉宽范围再根据公式反推 PSC。信号是微秒级就上 84MHz信号是毫秒级就用 1MHz量程和精度两头兼顾。第三多次测量取平均或者取中值。偶然的干扰脉冲对单次测量影响很大连续采 8 次去掉最大最小再平均数据立刻变得好看。第四中断里只做“读寄存器、切状态、置标志”把计算、打印、存储全部挪到主循环。这个原则能省掉 90% 的“数据偶尔跳一下”问题。5. 常见问题速查与避坑清单5.1 捕获中断进不去、回调不执行这一类问题占初学者踩坑的一半以上排查顺序很重要。先查 NVIC 使能。CubeMX 里如果没勾选 TIM3 global interrupt后面无论怎么调代码都不会进中断。再查启动函数用的是不是 HAL_TIM_IC_Start_IT而不是 HAL_TIM_IC_Start这两个接口功能差了一个“中断开关”。再查回调函数是不是写对了位置HAL 库里它是弱函数你自己重定义的时候签名一个字母都不能错。然后是 GPIO。PA6 必须配置成复用功能 AF2 才能连接到 TIM3_CH1。如果 CubeMX 里被配置成了普通输出或者外部中断捕获链路是断的信号进不去定时器。最后检查信号电平PA6 是 3.3V 逻辑别的设备输出 5V 甚至 12V轻则测不到重则烧引脚。5.2 数值偶尔跳变、明显不合理先看信号本身。杜邦线悬空、面包板接触不良、电机干扰耦合都会让边沿附近出现毛刺。有条件就上示波器看波形没条件就在 CubeMX 里把 Input Filter 设成 2 或 3能滤掉一部分窄毛刺但代价是边沿会有轻微延迟。如果信号源太脏还是老老实实加硬件整形。再看中断里的工作量。如果在回调里直接做浮点运算或调用 printf中断时间一长下一次边沿可能就错过了。漏掉一次下降沿的结果是下一次捕获到的下降沿其实是下一个周期的算出来的“脉宽”会大得离谱。代码规范做法是回调里只置标志测量结果的计算放主循环。如果用的是清零法还会遇到一个“貌似正常但永远偏小”的现象因为清零指令比真实边沿晚执行了几微秒测出来的高电平时间系统性偏小。这种问题改路线 A 就好了。5.3 计算结果翻车多半是单位或类型问题单位换算错误是最高频的翻车现场。1MHz 计数频率下tick 数就是微秒数没问题但你把 PSC 改成别的值以后别忘了一个 tick 已经不再是 1us。84MHz 时每 tick 约 11.9ns直接拿 tick 数当微秒误差会放大到几十倍。类型问题也很隐蔽。虽然 HAL_TIM_ReadCapturedValue 返回的是 uint32_t但如果你在中间步骤把它存进了一个 uint16_t 变量高位被截断结果自然不对。特别是 16 位定时器CCR 本身是 16 位但差值计算和溢出补偿必须用 32 位来做。Keil 用户注意 printf 浮点输出。默认情况下不开启 MicroLIB 的话%f 可能输出空或者乱码而且浮点打印本身极慢。测出来的时间想带小数点建议先输出整数 tick再在协议层做换算不要直接在主循环里 %f。5.4 排查工具与调试顺序我的习惯是自下而上验证。第一步先用信号发生器输出一个已知频率的方波接到 PA6排除外部信号源的问题。第二步用逻辑分析仪确认信号确实到了引脚排除接线问题。第三步在回调里置一个全局标志位主循环轮询看标志有没有变化排除中断链路问题。第四步直接打印原始 CCR 值手动算一遍和工具读数对照。这套顺序走完大多数“测不到”和“测不准”都能定位到具体环节。没有信号发生器的话就用板子上另一个定时器输出一路标称 1kHz 的 PWM接到 PA6 上自测。这个土办法我至今还在用十分钟内就能把测量代码验证得明明白白。6. 工程扩展多通道、DMA 与串口上报6.1 同一定时器两个通道分别测脉宽一块板子上经常要同时测多路信号。同一定时器的不同通道共享同一个 CNT 和 ARR但捕获和极性设置是各自独立的所以完全可以在 TIM3_CH1 和 TIM3_CH2 上分别跑自己的状态机。回调里用 htim-Channel 区分来源void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM3) return; if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { /* CH1 的状态机 */ } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { /* CH2 的状态机 */ } }要注意两个通道共用计数频率如果两路信号的脉宽量级差异太大比如一个几微秒、一个几十毫秒同一个 PSC 很难同时兼顾两者的分辨率和量程。这种场景建议拆成两个定时器分别处理各自配参数。6.2 DMA 方式连续采集HAL 还有一组 HAL_TIM_IC_Start_DMA 接口可以让每次捕获的 CCR 值自动搬进内存缓冲区不占用 CPU 时间。它的局限性在于DMA 是按照同一个捕获极性连续采的适合测连续上升沿的周期不能自动区分高低电平。要做真正的“周期脉宽”连续采集更合适的搭配是 PWM Input 模式配合 DMA或者用两个通道分别配置上升沿和下降沿捕获。这套玩法以后有机会单独写一篇这里先记住一个结论中断方案适合单点测量和低频场景DMA 方案适合高速连续采集。6.3 用串口把测量结果发出来实际产品里测量数据最终要给别人看最常见的是串口输出。主循环里加几行打印printf(CH1: high%lu us, period%lu us, duty%lu%%\r\n, (unsigned long)g_high_ticks, (unsigned long)g_period_ticks, (unsigned long)g_duty_percent);前提是先做好 printf 重定向把 fputc 指向你的串口外设。数据量大的时候建议用带帧头的二进制协议比如“帧头 长度 数据 校验”比 ASCII 文本稳定得多解析也快。上位机那边用串口助手或者 Python 脚本都能处理。最后分享一个我自己的习惯凡是测脉宽、测频率、测占空比这类时序量先用 HAL 把整条链路跑通再去想怎么用寄存器抠那几微秒。真正对性能敏感的部分优先选硬件方案而不是优化中断代码。手里没示波器时用另一个定时器输出标称 1kHz 的 PWM 接到输入捕获引脚能让你的测量代码在十分钟内被验证得明明白白——这个土办法我用了很多年一直没淘汰。